同一段 WebGL,Windows 正常、macOS 过曝:半透明 canvas 在 WKWebView/Metal 下的合成陷阱
本篇文章涉及的bug由agent修复并复盘
最近的项目里有一个 WebGL 组件:用分形噪声(fbm)加域扭曲(domain warping)画的流动云雾效果,半透明地叠在页面背景上。在 Windows 上开发(Tauri 桌面应用,WebView2 = Chromium 内核),效果一直正常。构建 macOS 版本(WKWebView = Safari 内核)在真机上一跑:暗色主题下边缘浮出一圈白点,亮色主题下整块发白;继续深挖,症状升级为亮色过曝白块、暗色实心硬边椭圆、外围一大圈白晕。
同一份 shader,逐字节相同的 JS,两个平台两种画面。排查当天在那台 Mac 上的实拍(图中红字条是应用内的调试浮层,后文会讲它的用处;浮层里标注的是当时正在试的开关组合):
暗色主题下的退化:云雾变成实心硬边椭圆,外圈一层白边
亮色主题下的退化:整块发白,只剩一团白晕
这篇按时间线写:四个全错的假设、真正起作用的诊断方法、根因,和最终的修复;文末还有写文章时补做的一页最小复现——用两条渐隐带把那次"消失的乘法"钉到了公式级。如果你只想要结论:macOS WKWebView 的 Metal 后端对"半透明 WebGL canvas"与页面的合成行为,和 Chromium/D3D 不一致。别让 canvas 参与半透明合成——在 shader 里自己完成与背景的混合,输出不透明的画面。
假设 1:浮点精度。 云雾用了基于 sin 的哈希函数,第一反应是 Apple GPU 上 mediump 真的只有 16 位,哈希在低精度下量化崩溃。把 fragment shader 全改 highp——没用。后来实测:这台 Mac 的 fragment mediump 精度位数是 23,本来就是满精度。假设排除。
假设 2:premultipliedAlpha 配置在兜底路径上丢了。 猜测 Mac 走了 experimental-webgl 的兜底创建路径,参数 premultipliedAlpha: false 被丢弃,导致合成公式不同。实测打印 getContextAttributes():context 类型是标准 webgl,premultipliedAlpha 也确实是 false,和 Windows 完全一样。假设排除。
假设 3:sin 哈希在 Apple GPU 上退化。 换成 Dave Hoskins 的无 sin 哈希整套重写——两个平台表现依旧各是各的样子。哈希与平台差异无关(后来还原了回去)。假设排除。
假设 4(方向对了但细节错):输出不透明 + 写死的背景色。 意识到可能是半透明合成的问题后,试了在 shader 里把云雾混合到一个写死的背景色上、再输出不透明画面——结果低透明度区域"显形"成一块看得见的矩形,四个角还有边框感。方向是对的,但两个细节错了(后面讲)。
四个假设的共同点:每一个都基于"听起来很合理"的单点推理,没有一个先去拿测量数据。四次全灭之后我停下来换了打法。
第一步:把调试信息直接画到屏幕上。 这台 2016 年的老 Intel Mac 上,Safari 因为 GPU 黑名单根本创建不了 WebGL context(标准和 experimental 两种方式都失败),意味着没有任何浏览器开发者工具可用。解法是在应用内做一个调试浮层,把 getContextAttributes() 的返回值、getShaderPrecisionFormat 的精度位数、WEBGL_debug_renderer_info 的显卡字符串直接渲染在屏幕角落——假设 1 和假设 2 就是这样被排除的。
第二步:写一个"测试卡"shader。 模仿显示器测试卡的思路,写一个诊断用 shader,把一屏分成四段已知图案:纯中灰的不透明色块、黑白渐变、半透明的单色渐隐、原版云雾效果。两个平台各截一张图。当时在应用里做的那版测试卡长这样(注意最右侧原版云雾的椭圆边缘,那一圈白点清晰可见):
应用内测试卡:灰块 / 黑白渐变 / 半透明渐隐 / 原版云雾,最右格边缘有一圈白点
第三步:像素级对比。 截图必须用系统截图输出 PNG(不要用手机拍屏幕,摩尔纹会毁掉一切),然后抽出同一行像素做数值对比:
ffmpeg -i shot.png -vf "crop=iw:1:0:540" -f rawvideo -pix_fmt rgb24 row.bin
再用十几行 node 脚本把两个平台的像素值逐个并排打印。
这一步直接定位了问题:纯中灰的不透明段,两个平台像素级一致(127 对 128);所有异常都只出现在透明度小于 1 的半透明段。 不是精度、不是哈希、不是 shader 数学——是"半透明 canvas 叠到页面上"的那一步合成。
WebGL canvas 输出 vec4(col, alpha) 这样的半透明画面时,"这块 canvas 如何与页面背景混合"是由浏览器的合成器执行的。Chromium 在 Windows 上走 D3D(经 ANGLE 转译),WKWebView 走 Metal——两者对半透明 WebGL canvas 的合成行为不一致。在 Metal 这条路径上,半透明部分出现系统性过曝,而且无论 GL 这边开不开混合、帧缓冲清不清干净都一样:这不是我的 GL 状态问题,是浏览器合成层的固有差异,在 shader 逻辑层面无解。
(这也顺带解释了为什么假设 1–3 全灭:它们都在 shader 内部找原因,而问题根本不在 shader 里。)
思路:既然浏览器的半透明合成不可信,就不让它合成。shader 自己把云雾混合到页面背景色上,输出永远不透明:
// 之前:输出半透明画面,与页面的混合交给浏览器合成器(Metal 下过曝)
gl_FragColor = vec4(col, alpha);
// 之后:在 shader 内完成混合,canvas 恒为不透明
vec3 blended = mix(u_bg, col, alpha * alpha);
gl_FragColor = vec4(blended, 1.0);
两个细节正是假设 4 错误的原因:
细节一:混合系数用 alpha * alpha,不是 alpha。 修复前那个好看的"边缘收窄渐隐"效果,其实并不是我设计出来的——它是浏览器混合管线把透明度平方了一次的副作用(premultipliedAlpha: false 的半透明 canvas 在页面合成时,视觉上等效于 bg*(1-α²)+col*α²)。而这个 α² 恰恰也是 Metal 路径上过曝的放大器。所以修复的本质是把两件事拆开:手动把 α² 写进混合公式,保住原来的形态;输出不透明画面,甩掉过曝。 如果用 α 的一次方去混合,低透明度区域残留的微弱雾色会让整个 canvas 显形成一块矩形——这就是假设 4 的第一处死因。
一个额外的收获:这个混合结果与原半透明版本在 Windows 上的画面是同一个公式——所以这次修复在 Windows 上逐像素不变,macOS 单方面追平,跨平台修复的理想形态。(提醒:等效指数取决于你的 shader 输出的是直通颜色还是乘过 α 的颜色——文末最小复现里,直通输出在正确合成下就是线性 α。动手改自己的组件之前,先和原版做一次像素级对照再定指数。)
细节二:背景色要每帧读真实值,不能写死。 页面背景带主题切换(还有 0.3 秒的颜色过渡动画),写死的背景色在主题切换时会露出一块颜色不同步的矩形——假设 4 的第二处死因。修法是每帧把页面当前的真实背景色喂给 shader:
const bg = getComputedStyle(document.body).backgroundColor;
// 解析成 [r, g, b] 归一化后,作为 u_bg 传入 shader
云雾之外的区域因此精确等于页面背景,圆角外没有接缝,主题过渡也能同步跟上。
修复的信心来源仍然是那张测试卡:不透明段两个平台像素级一致是实测结果,所以"输出不透明"这条路是可靠的——不是又一个听起来合理的推理。
写这篇文章时,把当时的排查手段浓缩成了一个单文件 HTML:三块 canvas 全部用相同的参数创建({ alpha: true, premultipliedAlpha: false }),唯一变量是 shader 的输出方式——A 输出半透明的 vec4(col, α),B 输出不透明的 in-shader 合成,C 是一张五行测试卡(比当时那版多加了一行"预乘风格"渐隐,这一行后来立了大功)。动画时间默认冻结在固定值,两台机器截到的是同一帧。
Windows(Chrome)上,A 和 B 肉眼无法区分,这是基线:
Windows / Chrome,暗色背景:A、B 两个面板画面一致,测试卡五行正常
同一个页面放进那台老 Mac 的 WKWebView(Safari 进不去,用一个四十行的 Swift 脚本起窗口加载),A 面板当场复现——实心硬边椭圆加白边,和一个月前应用里的画面如出一辙;B 面板则与 Windows 一致:
macOS / WKWebView,暗色背景:A 面板过曝成实心椭圆加白边,B 面板正常
macOS / WKWebView,亮色背景:A 面板整块发白,B 面板正常
真正有信息量的是测试卡的 ③④ 两行。半透明内容交给合成器时只有两种可能的正确姿势:直通(straight)内容按 col·α + bg·(1−α) 混合,预乘(premultiplied)内容按 col + bg·(1−α) 混合。测试卡把两种输出各放一行,合成器到底在执行哪个公式,一张截图就能判:

对两平台的截图做像素采样(雾色 tint = (168,199,173),暗色背景 bg = (16,21,15)):
| 采样点 | Windows | macOS |
|---|---|---|
| ① 不透明中灰(对照) | (127,127,127) | (128,128,128) |
| ③ 直通 α=0.9 | (153,181,157) | (170,201,175) |
| ③ 直通 α=0.5 | (92,110,94) | (177,209,181) |
| ③ 直通 α=0.1 | (31,39,31) | (183,217,187) |
| ④ 预乘 α=0.9 | (138,163,142) | (153,181,158) |
| ④ 预乘 α=0.5 | (50,60,51) | (92,109,95) |
| ④ 预乘 α=0.1 | (16,21,15) | (31,39,31) |
三个事实从表里直接读出来:
tint + bg。第 3 条就是铁证:这版 WebKit 对任何内容都在执行预乘公式 col + bg·(1−α)——premultipliedAlpha: false 被无视,源颜色上那次 ·α 被跳过了。所以你喂给它已预乘的内容(④ 行),它反而算对了。
这一条把前文所有症状串了起来:源颜色永远不被 α 削减、只往上叠背景,所以半透明区亮度只增不减——过曝;椭圆边缘 α→0 处输出逼近 tint + bg——那圈白边和白点;从"有雾"到"没雾"没有连续过渡——硬边。
顺带一提,这台 Mac 的 Safari 至今创建不了 WebGL context(GPU 黑名单,webgl 和 experimental-webgl 双双失败),所以复现页里专门写了个红色错误条,Mac 侧截图全部来自 WKWebView 壳:
同一台 Mac 上的 Safari:连 WebGL context 都建不出来,只剩页面自带的错误提示
复现件一共两个文件:测试页是无外部依赖的单 HTML;WKWebView 壳是四十行 Swift(swift wkshell.swift testcard.html 即可运行,装过 Xcode 命令行工具就行)。
调试 shader 期间发现:Vite 的热更新(HMR)对 WebGL 组件不可靠——热更新之后旧的 context、旧的编译状态经常还挂着,你盯着看的其实是上一版 shader。必须完全重启 dev server。我的土办法是每一轮在调试浮层上写一句不同的红字文案,当作"我现在跑的是第几版"的标记——听起来很笨,但它终结了至少两次"为什么改了没效果"的原地打转。
验证环境:2016 Intel MacBook(Intel HD 515,macOS 12.7)对比 Windows 11(RTX 5080)。硬件差了十年,最后证明和硬件没关系。最小复现截图:同两台机器,Windows 侧 Chrome(dpr 1.5),macOS 侧 WKWebView(dpr 2.0)。
评论加载中...